A Practical Guide for Reverse Engineers
(she/her, they/them)
Senior Security Researcher, CrowdStrike
February 28, 2025
Some of my previous work on Rust reversing:
Rust RE skills needed for:
Rust is becoming increasingly popular as a general purpose systems programming language.
We have:
We don’t have:
📖 The rustc Book > Codegen Options > strip
Note that, at any level, removing debuginfo only necessarily impacts “friendly” introspection.
-Cstripcannot be relied on as a meaningful security or obfuscation measure, as disassemblers and decompilers can extract considerable information even in the absence of symbols.
(emphasis added)
haha…yes…..we can totally extract considerable information…….
The RustyClaw malware, first publicly reported by Cisco Talos in October 2024, is a downloader used to deliver a backdoor: 🔗 UAT-5647 targets Ukrainian and Polish entities with RomCom malware variants
We will be looking at the sample with SHA-256 hash
🔗 Sample download from MalwareBazaar
This is an x86_32 Windows binary.
core::result::unwrap_failedSpecifically, we will be trying to annotate the code inside this one block as much as possible!
core::result::unwrap_failedHere’s a preview of the nicely annotated result.
core::result::unwrap_failedWe’ll also be reversing a function called inside this block.
core::result::unwrap_failed.core::result::unwrap_failedUnderstanding Rust types, from the source code side.
We can’t learn all of Rust today, but we will need to understand some Rust source code.
& :&:i64 -> Signed 64-bit integeru8 -> Unsigned 8-bit integerf32 -> 32-bit floating point valueusize, isize -> The size of a pointer, on whatever platform you’re on (think size_t, ssize_t)bool -> True, or False. Always a size of 1 byte.() -> The empty “unit” type.[u64; 128] -> An array of unsigned 64-bit integers, with 128 entries.Code examples in this talk are simplified for easier reading and clarity:
pub keywordmut keywordunsafeSyntax: &[T], where T is some type.
&[u8] is a sized view into a collection of unsigned 8-bit integers (u8).Suppose we have an array of bytes:
We can take a slice into that array:
let array_slice = &array[1..3];
println!("Slice contents from index 1 (inclusive) to 3 (exclusive): {:?}", array_slice);And query its length:
We will be focusing on just two string types:
&str
std::string::String
&str typeYou will see string slices (&str) for:
const char*):std::string::String:&str : Panic path metadata in binariesSee the following for more info:
std::string::String typestd::string::Stringstd::string::String typePutting it all together into a C-like representation:
core::fmt::Write
To do dynamic dispatch without having a concrete type, Rust uses another type of reference: the trait object, e.g. &dyn core::fmt::Write.
Drop TraitOne important trait that will be relevant for us later: The Drop trait.
This is a destructor - it gets called for non-primitive types when values go out of scope!
Vec<T>: impl Drop for Vec<T>📖 The Rust Reference > Type Layouts
bool, i64, f32, etc.) are guaranteed.&i64&str, &[u8], etc.&dyn core::fmt::Write📖 The Rust Reference > Type Layouts
These include:
These include:
⚠️ Rust’s calling convention for Rust-to-Rust function calls is neither stable, nor even defined.
Example from our RustyClaw binary (Windows x86_32) of (sort of) __fastcall:
Notice how a single &str is split across two registers here:
struct `&str`
{
uint8_t* _slice_data = "called Result::unwrap() on an Err value"
size_t _slice_len = 0x2b
};A C-like representation of our standard library Result type:
You will often see this idiom in decompiled Rust binaries:
struct std::io::error::Result<Vec<u8>> read_result;
std::fs::read::inner(&read_result, path_data, path_len);
uint64_t __discriminant = read_result.__discriminant;
if (__discriminant == 0x8000000000000000) {
core::result::unwrap_failed("called `Result::unwrap()` on an `Err` value", 0x2b, &read_result);
/* no return */
} else {
/* Read was successful, do stuff with read_result here... */
}Result from RustyClawConstructing the core::fmt::Arguments standard library type.
core::fmt::ArgumentsLet’s construct the standard library’s core::fmt::Arguments type.
println!From the programmer’s perspective, doing string formatting is quite easy:
library/core/src/result.rsprintln!println!, std::io::stdio::_printstd/src/io/stdio.rscore::fmt::Arguments Typestruct core::fmt::Arguments {
1 pieces: &[&str],
2 fmt: Option<&[core::fmt::rt::Placeholder]>,
3 args: &[core::fmt::rt::Argument],
}&[]), containing the string literals (&str) to put together: ["First number is: ", ", second number is: ", "\n"]
&[]), containing the dynamic values (core::fmt::rt::Argument) to display as strings: [86, 64]
&[&str]&[&str] is:
&[])&str)&[&str]Option<&[core::fmt::rt::Placeholder]>Option<&[core::fmt::rt::Placeholder]> is:
Option<>)&[core::fmt::rt::Placeholder]&[core::fmt::rt::Placeholder] is:
&[])core::fmt::rt::Placeholder structsNone variant of the union, when it holds no data.
Some variant of the union, when it holds an actual array slice (&[]) of core::fmt::rt::Placeholder values.
Option<&[core::fmt::rt::Placeholder]>&[core::fmt::rt::Argument]&[core::fmt::rt::Argument] is:
&[])core::fmt::rt::Argument structscore::fmt::rt::Argumentstruct core::fmt::rt::Argument {
1 value: &Opaque,
2 formatter: fn(_: &Opaque, _: &mut Formatter) -> Result,
}&) to a value (Opaque) to format into a string.
fn()) which does the actual string formatting.
core::fmt::rt::Argument&[core::fmt::rt::Argument]struct core::fmt::Arguments
{
struct &[&str] pieces
{
struct &str* data_ptr;
usize length;
},
struct Option<&[core::fmt::rt::Placeholder]> fmt
{
__discriminant_type discriminant;
union
{
&[core::fmt::rt::Placeholder] Some;
struct {} None;
}
}
struct &[core::fmt::rt::Argument] args
{
struct core::fmt::rt::Argument* data_ptr;
usize length;
},
};core::result::unwrap_failed examplecore::result::unwrap_failed examplecore::fmt::Argument variablescore::fmt::Argumentcore::fmt::Argument type&[core::fmt::Argument] variable&[core::fmt::Argument]&[core::fmt::Argument] type&[core::fmt::Argument]std::string::String, require a global allocator to be defined.fn std::sys::pal::windows::alloc::process_heap_alloc(
_heap: MaybeUninit<c::HANDLE>,
flags: u32,
bytes: usize,
) -> *mut c_void
{
let heap = HEAP.load(Ordering::Relaxed);
if core::intrinsics::likely(!heap.is_null()) {
unsafe { HeapAlloc(heap, flags, bytes) }
} else {
1 process_heap_init_and_alloc(MaybeUninit::uninit(), flags, bytes)
}
}HeapAlloc inside this function
Drop trait1fn __rust_dealloc(ptr: *mut u8, size: usize, align: usize);
fn __rdl_dealloc(ptr: *mut u8, size: usize, align: usize) {
unsafe { System.dealloc(ptr, Layout::from_size_align_unchecked(size, align)) }
}__rdl_dealloc, if you’re using the default standard library allocator
unsafe fn System::dealloc(&self, ptr: *mut u8, layout: Layout) {
let block = {
if layout.align() <= MIN_ALIGN {
ptr
} else {
// The location of the start of the block is stored in the padding before `ptr`.
// SAFETY: Because of the contract of `System`, `ptr` is guaranteed to be non-null
// and have a header readable directly before it.
unsafe { ptr::read((ptr as *mut Header).sub(1)).0 }
}
};
let heap = unsafe { get_process_heap() };
unsafe { HeapFree(heap, 0, block.cast::<c_void>()) };
}Recall the &dyn core::fmt::Write trait object. It includes:
core::fmt::Write traitThe vtable attached to trait objects has:
struct core::fmt::Write::_vtable alloc::string::String::_vtable =
{
1 void* (* destructor)(void* self) = core::ptr::drop_in_place<alloc::string::String>
2 int64_t size = 0x18
3 int64_t alignment = 0x8
4 void* (* write_str)(void* self, char* str_data, uint64_t str_len) = <alloc::string::String as core::fmt::Write>::write_str
void* (* write_char)(void* self, int32_t character) = <alloc::string::String as core::fmt::Write>::write_char
void* (* write_fmt)(void* self, Arguments* args) = core::fmt::Write::write_fmt
}usize constants, followed by a function pointer__rust_dealloc / __rdl_dealloc.Example likely vtable from an x86_64 binary with symbols
core::result::unwrap_failed functionlibrary/core/src/result.rsYou can implement the fmt::Debug trait for your type, to produce a convenient string representation of your type when debugging.
println!, panic!, etc.), via the {variable_name:?} syntax.#[derive(Debug)] onto your type:&dyn fmt::Debug trait objectcore::result::unwrap_failed in RustyClawNotice how our metadata-bearing pointer (&dyn fmt::Debug) is split across two variables again!
void core::result::unwrap_failed(
void* error_concrete_type_data, // `&dyn fmt::Debug`._concrete_type_data
struct fmt::Debug::_vtable* error_vtable, // `&dyn fmt::Debug._vtable
struct core::panic::Location* panic_location,
void* msg_data_ptr @ ecx, // `&str`._slice_data
void* msg_len @ edx // `&str`._slice_len
) { [...] }core::result::unwrap_failed(
&unwrapped_err, // error_concrete_type_data (`&dyn fmt::Debug`._concrete_type_data)
&unwrapped_err_vtable, // error_vtable (`&dyn fmt::Debug._vtable)
&panic_location_"src\is_windows7_or_below.rs"_line_37_col_59, // panic_location
"called `Result::unwrap()` on an `Err` value", // msg_data_ptr (`&str`._slice_data)
0x2b // msg_len (`&str`._slice_len)
);Note how this vtable likely only has one entry!
The fmt::Debug trait only requires the implementation of one method:
The vtable type will therefore look something like this:
fmt implementationstd::fmt::Formatterstruct `std::fmt::Formatter` __packed
{
__padding char _0[0x14];
__padding char _14[4];
__padding char _18[4];
};std::fmt::Formattercore::fmt::Formatterstruct Formatter {
flags: u32,
fill: char,
align: Alignment,
width: Option<usize>,
precision: Option<usize>,
buf: &dyn core::fmt::Write,
}Note our trait object, &dyn core::fmt::Write, here!
&dyn core::fmt::Write&dyn core::fmt::Write&dyn core::fmt::Writestd::fmt::Formatter: The buf: &dyn core::fmt::Write fieldfmt implementation againAfter defining Formatter, &dyn Write, and the Write vtable: We can now see an &str being passed to write_str!
fmt::Debug trait implementationRecall that you can just use the #[derive(Debug)] to get the compiler to generate a sensible fmt::Debug representation:
#[derive(Debug)]
struct Coordinates {
x: i64,
y: i64,
}
fn main() {
let cursor_position = Coordinates { x: -100, y: 120 };
println!("{cursor_position:?}");
}This prints the name of the type, and all its fields!
NulError typeNulError typeWe actually have the size and alignment of this type already, from the vtable!
NulError typestd::ffi::NulError type📖 Docs: std::ffi:NulError
An error indicating that an interior nul byte was found. While Rust strings may contain nul bytes in the middle, C strings can’t, as that byte would effectively truncate the string. This error is created by the
newmethod onCString.
std::ffi::NulError typestd::ffi::NulError typestd::ffi::NulError type_<impl fmt::Debug for std::ffi::NulError>::fmt functionstd::ffi::NulErrorThere is quite a lot you can figure out just by reading!
You can also find me at:
illustration of how we all feel
Thank you to:
The slide template used here is Grant McDermott’s quarto-revealjs-clean.
This presentation would not be possible without the huge amount of documentation, blogs, tutorials and public resources published by the Rust community.
- Matt Oswalt: Polymorphism in Rust: https://oswalt.dev/2021/06/polymorphism-in-rust/
- Marco Amann: Rust Dynamic Dispatching deep-dive: https://medium.com/digitalfrontiers/rust-dynamic-dispatching-deep-dive-236a5896e49b
- Raph Levien: Rust container cheat sheet: https://docs.google.com/presentation/d/1q-c7UAyrUlM-eZyTo1pd8SZ0qwA_wYxmPZVOQkoDmH4/edit#slide=id.p
- Mara Bos: Behind the Scenes of Rust String Formatting: format_args!(): https://blog.m-ou.se/format-args/
- Rust to Assembly: Understanding the Inner Workings of Rust: https://www.eventhelix.com/rust/
- fasterthanlime - Peeking inside a Rust Enum: https://fasterthanli.me/articles/peeking-inside-a-rust-enum
- Rust Language Cheat Sheet: https://cheats.rs/
- Primitive Type fn: ABI Compatibility of Rust-to-Rust calls: https://doc.rust-lang.org/core/primitive.fn.html#abi-compatibility
- The Rust Reference: Dynamically Sized Types: https://doc.rust-lang.org/reference/dynamically-sized-types.html
- The Rust Reference: Type Layout: https://doc.rust-lang.org/reference/type-layout.html
- The Rust Reference: Destructors: https://doc.rust-lang.org/reference/destructors.html
- Changes to `u128`/`i128` layout in 1.77 and 1.78: https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
- The Rustonomicon: https://doc.rust-lang.org/nightly/nomicon/
- Exploring dynamic dispatch in Rust: https://alschwalm.com/blog/static/2017/03/07/exploring-dynamic-dispatch-in-rust/
- Rust Deep Dive: Borked Vtables and Barking Cats: https://geo-ant.github.io/blog/2023/rust-dyn-trait-objects-fat-pointers/
- About `vtable_allocation_provider`: https://www.reddit.com/r/rust/comments/11okz75/comment/jbt969m/
- https://github.com/rust-lang/rust/pull/86461/files
- https://github.com/rust-lang/rust/blob/1.83.0/compiler/rustc_middle/src/ty/vtable.rs
- How is `__rust_dealloc` function connected to `__rdl_dealloc` function?: https://users.rust-lang.org/t/how-is-rust-dealloc-function-connectted-to-rdl-dealloc-function/122159
- What is difference between a unit struct and an enum with 0 variants?: https://www.reddit.com/r/rust/comments/1hw19el/what_is_difference_between_a_unit_struct_and_an/